iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
Modern Web

《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記系列 第 4

Day 4|錯位的參數:useFactory 的 inject 陣列寫錯,為什麼傳進來的依賴全對不上?

  • 分享至 

  • xImage
  •  

當一個 provider 需要根據多個依賴動態建立時,useFactory 是 NestJS 常見的做法之一。這種寫法看似直觀,背後卻隱藏著一個容易被忽略的位置對應陷阱。

這種錯位最棘手的地方在於它的高度靜默性:tsc 編譯通過、應用程式也能正常啟動,甚至回傳的結構都完全合規——唯獨裡面的資料內容無聲無息地對調了。

如果沒有相應的測試防線,這類錯誤很容易一路溜進正式環境,直到發出異常請求時才被發現。

問題怎麼發生?

我們先從一個簡單的範例來看,假設我們需要把兩個獨立的設定值組合為 PostConfig 物件,並提供給 Controller 注入使用。

首先定義這份設定檔的介面:

// post-config.interface.ts
export interface PostConfig {
  apiBaseUrl: string;
  defaultAuthor: string;
}

然後在模組裡註冊兩個原始設定值,再用一個 factory 把它們組起來:

// post-config.module.ts
@Module({
  controllers: [PostConfigController],
  providers: [
    {
      provide: 'POST_API_BASE_URL',
      useValue: 'https://posts.example.test',
    },
    {
      provide: 'DEFAULT_AUTHOR',
      useValue: 'YUN',
    },
    {
      provide: 'POST_CONFIG',
      // ❌ 地雷:Nest 依照 inject 陣列的位置傳值,不會依參數名稱配對。
      inject: ['DEFAULT_AUTHOR', 'POST_API_BASE_URL'],
      useFactory: (apiBaseUrl: string, defaultAuthor: string): PostConfig => ({
        apiBaseUrl,
        defaultAuthor,
      }),
    },
  ],
})
export class PostConfigModule {}

Controller 用字串 token 注入:

// post-config.controller.ts
@Controller('posts/config')
export class PostConfigController {
  constructor(
    @Inject('POST_CONFIG')
    private readonly postConfig: PostConfig,
  ) {}

  @Get()
  getConfig(): PostConfig {
    return this.postConfig;
  }
}

這段程式碼能順利通過型別檢查並正常啟動。

接下來,我們實際發個請求來驗證結果:

curl http://localhost:3000/posts/config
{
  "apiBaseUrl": "YUN",
  "defaultAuthor": "https://posts.example.test"
}

兩個設定值完全顛倒了。

apiBaseUrl 誤塞了作者名稱,而 defaultAuthor 則拿到了網址。如果其他模組直接拿這份設定去組合 API 路徑,便會向 https://YUN/... 發出錯誤請求,且這種錯誤通常要等到實際呼叫時才會暴露。

根因:inject 是位置參數清單,不是具名對應表

NestJS 的官方文件給出了非常明確的說明:

Nest will pass instances from the inject list as arguments to the factory function in the same order.——NestJS 官方文件(Custom providers)

可以把這個機制理解成兩個步驟:

  • 按順序解析:依序解析 inject 陣列裡的每一個 token,取得對應的實例,組合成一個陣列。
  • 位置展開:將該陣列以引數(arguments)的形式展開後傳入 useFactory 函式。

若把這個邏輯套回剛才的範例:

索引 inject 的 token 解析出的值 傳入的參數位置
0 'DEFAULT_AUTHOR' 'YUN' 第 1 個參數(apiBaseUrl
1 'POST_API_BASE_URL' 'https://posts.example.test' 第 2 個參數(defaultAuthor

實際發生的事情等同於這一行:

useFactory('YUN', 'https://posts.example.test');

理解這個機制後問題就很明顯了:useFactory 函式裡的參數名稱只是寫給開發者看的。換句話說,Nest 並不存在「根據參數名稱尋找同名 provider」這套機制。

為什麼連 TypeScript 型別檢查都攔不住?

這個錯位之所以能輕鬆逃過型別檢查,原因在於 inject 陣列與 useFactory 參數之間的對應關係,在 NestJS 的型別定義中完全是解耦的:

// @nestjs/common 的型別定義
export interface FactoryProvider<T = any> {
  useFactory: (...args: any[]) => T | Promise<T>;
  inject?: Array<InjectionToken | OptionalFactoryDependency>;
}

export type InjectionToken<T = any> =
  | string | symbol | Type<T> | Abstract<T> | Function;

如果我們仔細來看,會發現原本仰賴的三道潛在防線會依序失守:

  • 第一道防線:TypeScript 參數比對
    useFactory 在官方型別定義中被宣告為 (...args: any[]) => ...。因此,即使你在實作時手動標註了 (apiBaseUrl: string, defaultAuthor: string),TypeScript 也只會把它當成一般函式,根本不會去拿它的參數簽名與 inject 陣列做長度或順序的比對。
  • 第二道防線:Token 與解析結果沒有型別關聯
    NestJS 雖然定義了泛型形式的 InjectionToken<T>,但 FactoryProvider 並沒有利用這個泛型,把 inject 中每個 token 的解析型別與 useFactory 對應位置的參數型別串起來。尤其使用字串或 Symbol token 時,token 本身在 runtime 只是一個識別值,無法表達「這個 token 解析後一定是某個特定型別」。
  • 第三道防線:相同型別無法表達語意差異
    就算 TypeScript 未來能設法將 token 與參數型別串起來,由於這兩個依賴的型別恰好都是 string,在靜態型別系統眼裡完全相容,根本無法判斷「網址」與「作者名稱」在語意上的差異。

排雷指南

解法一:對齊 inject 陣列與 useFactory 的參數順序

最直接的解法,就是確保 inject 陣列裡的 token 順序與 useFactory 的參數列完全一致:

{
  provide: 'POST_CONFIG',
  inject: ['POST_API_BASE_URL', 'DEFAULT_AUTHOR'],
  useFactory: (apiBaseUrl: string, defaultAuthor: string): PostConfig => ({
    apiBaseUrl,
    defaultAuthor,
  }),
}

將順序對齊後,我們再次打一次 API 驗證,這時回傳的 JSON 資料欄位與數值都正確了:

{
  "apiBaseUrl": "https://posts.example.test",
  "defaultAuthor": "YUN"
}

特別提醒,這個解法是一個必要的修正,不是最強的防錯手段。

解法二:減少位置依賴,把相關資料聚合成單一物件

當多個值本來就描述同一個概念時,與其把它們拆成多個基礎型別的純值 provider(如 stringnumber),再依靠 inject 的位置重新組合,不如一開始就把相關設定聚合成一個具名物件。

想像一下,如果我們需要注入資料庫連接設定,原本的做法可能會傳入一長串參數:

useFactory(
  host: string,
  port: number,
  username: string,
  password: string,
  timeout: number,
) => new DatabaseConnection(...)

較佳的做法是將這些相關欄位收斂成一個 DatabaseOptions 介面,並註冊為單一 provider:

interface DatabaseOptions {
  host: string;
  port: number;
  username: string;
  password: string;
  timeout: number;
}

並讓這組設定以單一 provider 存在:

{
  provide: 'DATABASE_OPTIONS',
  useValue: {
    host: 'localhost',
    port: 5432,
    username: 'app',
    password: 'secret',
    timeout: 5000,
  } satisfies DatabaseOptions,
}

後續建立連線時,就只需要注入這單一物件:

{
  provide: 'DATABASE_CONNECTION',
  inject: ['DATABASE_OPTIONS'],
  useFactory: (options: DatabaseOptions) =>
    new DatabaseConnection(options),
}

這時 factory 只有一個位置參數,欄位之間的對應改由 options.hostoptions.port 這類具名屬性表達,不再依賴多個參數的位置順序。

解法三:組裝邏輯開始變複雜時,改用 class-based provider

當 factory 開始包含預設值、條件判斷或多步驟初始化時,可以改用 class-based provider:

@Injectable()
export class PostConfigService implements PostConfig {
  constructor(
    @Inject('POST_API_BASE_URL')
    readonly apiBaseUrl: string,

    @Inject('DEFAULT_AUTHOR')
    readonly defaultAuthor: string,
  ) {}
}

改用此解法的最大好處,在於 @Inject() 裝飾器能直接與建構子參數綁定在一起,不必再同時維護 inject 陣列與 factory 參數列這兩份順序清單。

簡而言之,單純且靜態的設定組裝適合用 useFactory;一旦需要封裝業務邏輯或狀態規則時,直接升級為 class 會更加穩健。

解法四:在邊界做 Validation,讓錯誤提早失敗

當多個依賴的型別相同時,即使參數位置放反,TypeScript 的靜態檢查也無法察覺。針對這類重要設定,我們可以在物件建立時加入 runtime validation:

const postConfigSchema = z.object({
  apiBaseUrl: z.url(),
  defaultAuthor: z.string().min(1),
});
useFactory: (source: PostConfigSource): PostConfig =>
  postConfigSchema.parse({
    apiBaseUrl: source.apiBaseUrl,
    defaultAuthor: source.defaultAuthor,
  })

這樣 apiBaseUrl 若拿到 'YUN',應用會在初始化 provider 時直接失敗,而不是等到第一次發送 HTTP 請求才暴露問題。

需注意的是,Schema 驗證只能幫我們攔截「錯位後違反格式規則(如將非 URL 字串傳給 API Base URL)」的情況;如果兩個欄位的驗證規則完全一致,仍必須仰賴前述的架構設計與單元測試來發揮防禦作用。

總結

  1. inject 是位置參數清單:Nest 依照索引把解析結果傳進 useFactory,不會根據參數名稱重新配對。
  2. 同型別依賴可能靜默錯位:兩個值都是 string 時,TypeScript、應用啟動與回傳結構都可能正常,只有資料內容放錯位置。
  3. 相關設定可以聚合成物件:像 database options 這類同一概念的資料,可以改用單一物件與具名欄位,減少位置依賴。
  4. 依複雜度補上防線:組裝邏輯變複雜時可改用 class-based provider;重要設定再搭配 runtime validation,讓錯誤更早被發現。

參考資料


上一篇
Day 3|消失的 IoC 控制權:為什麼手動 new Service() 會繞過 NestJS 的 DI?
下一篇
Day 5|錯覺的順序:ConfigService 為什麼注入失敗?別被 imports 陣列順序騙了
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言